iT邦幫忙

2025 iThome 鐵人賽

DAY 1
0

前言

Hello,大家好,我是一名年資屆滿兩年的後端工程師,今年是首次參加 iThome 的鐵人賽,剛好最近開始為了年底的面試在做準備,希望藉由這個機會帶來更大的動力跟自制力,讓「我獨自升級」的速度大幅提升。身為一個從文組轉職的軟體工程師,一直以來崇尚實用主義,以解決當下碰到的技術問題為優先,常會面臨到「知其然,卻不知所以然」的狀況,這次通過幾個經典案例的 Lab 實作,去思考那些軟體開發背後的脈絡,進而將這些概念整理、內化到自身的知識庫。

而對於這個比賽來說,我期許自己「先完整,再完賽」,用心對待每一篇文章、每一次的實作、每個概念的理解,而不單單只是為了比賽而交作業,我相信當我敲下鍵盤的這一刻,我就已經贏過那個不敢行動的自己了,所以我不求榮譽、不求社群的認可、不用寫出多炫砲的技術文章,只想練好屬於我的一招一式。

接下來 30 天我會以 2 個系統設計的經典案例作為整體系列的骨幹,討論設計的思路、邏輯的推演,以及開發 Demo 時衍生的概念及知識,主要內容分為 3 大項:

  1. 系統設計經典案例實作
  2. 設計模式與框架應用
  3. 中間件的整合

而上述提到的經典案例分別為:

  • 網路限流器
  • 通知系統

選擇這兩個系統作為主題實作,是因為它們跟我當前的日常工作密不可分,但大部分的模組都在我入職前都已經被前人開發完畢,也因此並不清楚細節,在這段時間內我將會用我的方式來實作這些系統。首先,在實作業務邏輯前,我們需要為後端服務器加上一層防護,而這個防護機制就是所謂的「網路限流器」。

限流器

從英文的字面上更好理解限流器的用途,所謂 Rate Limiter 就是用來控制、限制系統在任務處理的「頻率」,用來保護系統不被短時間大量的任務壓垮、防止資源被濫用和惡意攻擊,總之都是為了確保系統、服務的穩定性。而限流類型大致上可劃分為客戶端限流,以及服務端限流:

  • 客戶端限流:
    • 在使用者端 UI 操作時就限制請求的頻率,減少對服務端的壓力。
    • 但若碰上惡意攻擊,如 DoS 的話則無法防禦,因為容易繞過前端的限流機制,直接對 API 進行大量訪問。
  • 服務端限流:
    • 在微服務的 API Gateway、Reverse Proxy Server 如 Nginx,或在服務層實施限流機制,直接保護後端資源。
    • 搭配分布式儲存 (Redis) 做全域限流。

而限流器還有一個重點在於防止**失效擴散 (cascading failure),即一個服務因過載崩潰,可能連帶拖垮其他服務,**其餘用途包含但不限於以下原因:

  • 防止突發的流量高峰壓垮系統,如流量洪峰、惡意攻擊。
  • 保護下游的服務,假設某微服務負責調用三方 API,而三方本身也有訪問頻率的限制,為了不發生調用的失敗,因此會在上層就做防護。
  • 類似於上述的用途,調用三方提供的服務勢必會產生費用,如雲端服務或 LLM 的調用,為了不產生預期外的成本,也需要做服務的限流。

實作 Lab 1:固定視窗計數器(Fixed Window Counter)

主要限流算法分為以下幾類,而 Lab 1 將從最簡單的固定視窗計數器限流算法開始實作單機版本的服務,把所有算法的基礎功能都完成後,會朝向分布式架構邁進:

  • Fixed Window Counter
  • Sliding Window Counter
  • Token Bucket
  • Leaky Bucket

什麼是固定窗口?

一言以蔽之,給每個「固定的時間窗口」分配一個計數器,每個請求訪問進來都會在計數器上加一,達到設定上限之後進來的請求就會被擋下。固定窗口計數器的算法一大優點就是實作起來非常簡單、效率高,但限流效果就相對不這麼可靠,因為在時間窗口的交界處容易產生流量突次問題,無法擋下預期外的流量,舉例而言:

  • 假設我的固定窗口固定為 1 分鐘可接收 100 個請求。這時第 1 個窗口的第 59 秒瞬間進來 100 個請求。
  • 下一秒進到了第 2 個窗口,計數器歸 0,馬上又有 100 個請求進來,這些請求也會被處理到。
  • 這短短 1 秒內就有 200 個有效請求,跟原先設置的 1 分鐘 100 個請求有極大差距。
  • 了解它的侷限對於接下來理解滑動窗口帶來的優化是有必要的。
    https://ithelp.ithome.com.tw/upload/images/20250821/20161582M7TjYM1FQR.png

實作環節

接下來直接進入到實作環節,用的是 Spring Boot 的專案進行實作,並且採用 AOP (切面導向,Aspect Oriented)的模式限流,在這個 Lab 裡我們會用到的組件如下:

  1. 一個切面 @Aspect class :負責掃描、攔截有用到 @RateLimiter 限流器註解的方法,並執行限流的判斷邏輯。
  2. 自定義的 Annotation @RateLimiter :可設置限流相關參數,並供系統在 Runtime 時可動態攔截、判斷該方法使用哪種限流算法、規則。
  3. 計數器 class:負責累計、清理單一來源的請求數量,根據 key 為每個來源統計獨立的請求數。
  4. key 產生器:為每個請求來源都創建一個獨有的 key 來分類加總。
  5. 限流算法的實作 class:具體實作固定窗口的算法邏輯。

@Aspect 類別

AOP 概念圖:

  • 以高層次理解 AOP 概念的話,就是在不同方法重複邏輯處一刀切,全部抽象到切面去,再用 Annotation 識別是否需要執行該邏輯,舉例來說 @Transactional 就是幫忙把事務管理的邏輯實作好了,只需要標記在方法上就會在執行的前後 (Around) 執行事務管理邏輯,不用手動實作,使得開發者專注在主要邏輯的實作就好。
    https://ithelp.ithome.com.tw/upload/images/20250821/201615827QGIyntOJP.png
    下面是這次 Lab 1 的切面實作類,有幾個重點:

  • @Around("@annotation(rateLimiter)") :切那些頭上有 @RateLimiter 的方法,切到後先執行限流器邏輯。

  • return joinPoint.proceed(); :執行完切面的限流算法後,把一刀切的面接回原呼叫的方法。

  • rateLimiterKeyGenerator.generateKey(joinPoint, rateLimiter); :限流器需要一個 key 來區分各個獨立計數器,這邊 key 的範圍很大,是用每個 API 當作限制請求次數的來源,再縮小的話可能會加 ip 到 key 上,後面再說。

@Slf4j
@RequiredArgsConstructor
@Aspect
@Component
public class RateLimitAspect {

    private final RateLimiterKeyGenerator rateLimiterKeyGenerator;
    private final FixedWindowLimiter fixedWindowLimiter;

    @Around("@annotation(rateLimiter)")
    public Object around(ProceedingJoinPoint joinPoint, RateLimiter rateLimiter) throws Throwable {
        var key = rateLimiterKeyGenerator.generateKey(joinPoint, rateLimiter);
        var isAllowed = fixedWindowLimiter.isAllowed(key, rateLimiter.limit(), rateLimiter.window());

        if (!isAllowed) {
            var method =  joinPoint.getSignature().toShortString();
            log.warn("Rate limit exceed for method: {}, key: {}", method, key);
            throw new BaseException(StatusCode.TOO_MANY_REQUEST,
                    String.format("Rate limit exceeded. Max %d requests per %d seconds",
                    rateLimiter.limit(), rateLimiter.window()));
        }
        
        log.debug("Rate limit passed for key: {}", key);
        return joinPoint.proceed();
    }
}

自定義 Annotation @RateLimiter

與上述切面類別對應的限流器註解,用來在方法上配置限流策略:

  • @Target(ElementType.METHOD) :定義這個註解只能用在方法上。
  • @Retention(RetentionPolicy.RUNTIME) :註解資訊保留到 Runtime 期間,讓 AOP 可讀取並處理。
  • @Documented :註解會被包含在 JavaDoc 中。
  • fallback()fallbackClass() :當限流觸發上限時的降級策略,可指定降級方法或降級類別。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {

    Algorithm algorithm() default Algorithm.FIXED_WINDOW;

    String key();

    int limit() default 10;

    int window() default 60;

    String fallback() default "";

    Class<?> fallbackClass() default Void.class;

    enum Algorithm {
        FIXED_WINDOW,
        TOKEN_BUCKET,
        SLIDING_WINDOW_LOG,
        SLIDING_WINDOW_COUNTER,
        LEAKY_BUCKET
    }
}

計數器類別

接下來是計數器的實作,幾個重點:

  • ConcurrentHashMap :為了妥善處理單體環境下數據儲存結構的併發任務,使用的是可支援多線程共享的 ConcurrentHashMap ,它透過把數據結構分段,讓每個線程可在同時間分別去訪問不同區段的資料,訪問期間利用分段鎖做細粒度的把控,讓每個資料佔用的範圍變得很小,也進而縮短了訪問時間,避免線程間等待時間過長,是個在高併發環境下適合使用的數據結構。

Java 以後已經不用分段鎖了,改用 CAS 操作 (Compare-And-Swap),但是底層的核心概念都是「ConcurrentHashMap 透過細粒度的鎖控制,讓不同的資料區段可以被多個線程同時安全存取,減少線程間的等待時間。」

  • ScheduledExecutorService 生命週期:ScheduledExecutorService 是在類加載時,從 ThreadPool 中取得一個 Thread 來創建的,他的作用域是整個應用程序的生命週期 static,一直存活在背景執行緒用於每 30 秒執行一次清理任務,完全獨立於當前執行緒。

    private static final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    
  • 為何 Counter 要用 AtomicLong :假設同一個 key 有多個請求進來,都要對同個計數器 +1,每一次 +1 的操作其實可以拆解成 3 個步驟:read → +1 → write ,那假設用普通的 long 累計的話,多線程可能讀取到相同的值,假設是 5,都對它 +1,然後都把 6 寫回計數器,這樣明明應該加 2 次,計數卻只增加 1 次。而 AtomicLong 保證計數為原子操作(基於 CAS),要嘛成功、要嘛失敗,確保操作完整性,它會把原先讀取的值連同增加的值一起提交,提交時發現當前的值已經不再是 5 了,便會 rollback 回去重新讀取成 6,然後更新為 7。

@Component
public class MemoryRateLimiterStorage implements RateLimiterStorage {

    private static final ConcurrentHashMap<String, Counter> storage = new ConcurrentHashMap<>();
    private static final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

    public MemoryRateLimiterStorage() {
        scheduler.scheduleAtFixedRate(this::cleanExpireKey, 30, 30, TimeUnit.SECONDS);
    }

    @Override
    public long incrementAndSetExpire(String key, long expireSeconds) {
        var expireTime = System.currentTimeMillis() + expireSeconds  * 1000;
        var counter = storage.compute(key, (k, existing) -> {
            if (existing == null || existing.isExpired()) {
                return new Counter(new AtomicLong(1), expireTime);
            } else {
                existing.count.incrementAndGet();
                return existing;
            }
        });
        return counter.count.longValue();
    }

    private void cleanExpireKey() {
        storage.entrySet().removeIf(entry -> entry.getValue().isExpired());
    }

    private record Counter(AtomicLong count, long expireTime) {
        public boolean isExpired() {
            return System.currentTimeMillis() > expireTime;
        }
    }
}

限流邏輯實作

固定窗口實作起來很單純,就是取得時間窗口內的當前計數值,用它去跟限流器的設定比大小即可。

@RequiredArgsConstructor
@Component
public class FixedWindowLimiter {

    private final RateLimiterStorage storage;

    public boolean isAllowed(String key, int limit, int windowSeconds) {
        var currentCount = storage.incrementAndSetExpire(key, windowSeconds);
        return currentCount <= limit;
    }
}

測試 API

最後來用個 Controller 來測試下效果。

@RequestMapping(value = "/rate")
@RestController
public class RateLimiterTestController {

    @RateLimiter(key = "demo", limit = 5)
    @GetMapping(value = "/window/fixed")
    public ResponseEntity<String> getFixedWindowString() {
        return ResponseEntity.ok("Rate limiter test");
    }
}

實際發送 API,實際上我在 37 分時送了 3 個請求,但到了 38 分開始我又送了 5 個,直到第 6 個送出才報錯,所以總計 1 分鐘內我實際上送了 8 個請求,這就是固定窗口計數器算法不可靠的地方。

{
    "error": "too_many_request",
    "message": "Rate limit exceeded. Max 5 requests per 60 seconds"
}

總結

雖然前面介紹固定窗口時,好像把它說得不太可靠,但事實上在真實使用場景中,固定窗口的使用率還是蠻高的,原因是它的優點也蠻明顯,那就是

  • 資源使用效率高,記憶體用量少
  • 實作起來簡單很多,維護成本也相對低

很多場景下不需要過於複雜的限流算法,若不是特別需要應付突如其來的流量高峰,或對精確性有高要求的服務,那固定窗口其實就可以滿足大多數需求了。

明天的內容會以今天的成果為出發:

  • 介紹滑動窗口計數器如何優化固定窗口限流不可靠的缺陷。
  • 並用設計模式中的「策略模式」和「工廠模式」來擴展現有的組件。

下一篇
Day 2 | 限流器 Lab 2 實作:滑動窗口計數器&設計模式
系列文
系統設計一招一式:最基本的功練到爛熟就是殺手鐧,從單體架構到分布式系統的 Lab 實作筆記14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言